API Design Patterns — 14장 연관 리소스 딥다이브 학습 노트
출처: API Design Patterns (JJ Geewax) | 참고: https://google.aip.dev/
14장에서 다루는 내용
- 연관 리소스를 통해 다대다 관계를 다루는 방법 — DB의 join table을 API 리소스로
- 연관 리소스 이름 명명법 — Membership, Association, Enrollment 등
- 연관 리소스의 표준 메서드 — Create, Get, List, Update, Delete + 별칭 메서드
- 고유성 제약과 읽기 전용 필드 — 중복 방지, 참조 불변성
- 참조 무결성을 다루는 방법 — 캐스케이드, 제한, null 설정, 방치
전체 흐름도
[다대다 관계] [연관 리소스] [구현 세부사항]
| | |
v v v
+------------------+ +---------------------+ +---------------------------+
| User는 여러 | | | | 14.3.1 이름 붙이기 |
| Group에 속하고, |---->| Membership 리소스 |---->| CourseMembership, |
| Group은 여러 | | (관계를 리소스로) | | UserGroup 등 |
| User를 가짐 | | | +---------------------------+
+------------------+ +---------------------+ |
| +---------------------------+
DB의 join table | | 14.3.2 표준 메서드 |
┌─────────┐ v | Create: 관계 생성 (필수) |
│ user_id │ +-----------+ | Delete: 관계 제거 (필수) |
│ group_id│ |Membership | | List: 연관성 나열 (필수) |
│ role │ | {userId, | | Get/Update: 메타 시 필요 |
│ joined │ | groupId, | +---------------------------+
└─────────┘ | role, | |
| expire} | +---------------------------+
+-----------+ | 14.3.3 고유성 |
| 같은 쌍은 1개만 존재 |
+---------------------------+
|
+---------------------------+
| 14.3.4 읽기 전용 필드 |
| userId/groupId 변경 불가 |
+---------------------------+
|
+---------------------------+
| 14.3.5 별칭 메서드 |
| ListUserGroups(userId) |
| ListGroupUsers(groupId) |
+---------------------------+
|
+---------------------------+
| 14.3.6 참조 무결성 |
| 캐스케이드/제한/방치 |
+---------------------------+
선수 지식 체크리스트
- [ ] REST API 기본 개념 (리소스, 엔드포인트, HTTP 메서드)
- [ ] 표준 메서드 (Get, Create, Update, Delete, List)
- [ ] 교차 참조 (13장) — 일대다 관계, 참조 필드의 기본
- [ ] DB의 join table 개념 (선택)
핵심 키워드
| 키워드 | 의미 |
|---|---|
| 연관 리소스 | 두 리소스 간의 다대다 관계를 표현하는 독립 리소스 (DB의 join table에 해당) |
| 다대다 관계 | 한 리소스가 여러 상대와, 상대도 여러 리소스와 연결되는 관계 |
| 관계 메타데이터 | 관계 자체에 대한 추가 정보 (역할, 가입일, 만료일 등) |
| 별칭 메서드 | 자주 쓰이는 필터링 쿼리를 단순화한 메서드 (ListUserGroups 등) |
| 고유성 제약 | 같은 두 리소스 간 연관 리소스는 하나만 존재해야 하는 제약 |
| 읽기 전용 필드 | 생성 후 변경할 수 없는 필드 (userId, groupId) |
14.1 서론 — 왜 연관 리소스가 필요한가?
한 줄 요약
단방향(일대다) 관계는 간단하지만, 다대다 관계와 관계 메타데이터가 필요하면 연관 리소스가 필요하다!
쉬운 설명 (학교 동아리 비유)
학교에서 학생과 동아리의 관계를 생각해보자.
- 학생 한 명이 여러 동아리에 가입할 수 있다
- 동아리 하나에 여러 학생이 속할 수 있다
- 관계에 추가 정보가 있다: "언제 가입했는지", "회장인지 부원인지"
이것은 다대다 관계이며, 관계 자체에 메타데이터가 필요하다.
학생 A ──── 축구부 (역할: 회장, 가입: 2024-03)
학생 A ──── 밴드부 (역할: 부원, 가입: 2024-05)
학생 B ──── 축구부 (역할: 부원, 가입: 2024-04)
학생 B ──── 미술부 (역할: 회장, 가입: 2024-01)
DB에서는 이걸 join table로 처리한다. API에서는? → 연관 리소스(Membership)!
13장(교차 참조)으로 다대다를 처리할 수 없는 이유
# 13장 방식: User 리소스에 groupIds 배열을 저장
interface User {
id: string;
name: string;
groupIds: string[]; // ← 속한 그룹 목록
}
# 문제 1: 관계 메타데이터를 어디에 저장?
# "Jimmy가 축구부에서 회장인지 부원인지" 정보를 User에? Group에? → 둘 다 어색함!
# 문제 2: 양방향 탐색이 어려움
# "축구부에 속한 모든 학생" → 모든 User를 스캔해야 함 (비효율!)
# 문제 3: 배열이 커지면 성능 문제
# 한 사용자가 1000개 그룹에 속하면 User 리소스가 비대해짐
실무 예제 — 온라인 강의 플랫폼
인프런 같은 강의 플랫폼을 생각해보자:
- 학생은 여러 강의를 수강할 수 있다
- 강의에는 여러 학생이 있을 수 있다
- 관계에 메타데이터가 있다:
- 수강 시작일 (enrolledAt)
- 진도율 (progress: 0~100%)
- 수료 여부 (completed: true/false)
- 수강권 만료일 (expireTime)
→ CourseEnrollment 연관 리소스로 해결!
// 연관 리소스로 모든 정보가 깔끔하게 표현됨
interface CourseEnrollment {
id: string;
studentId: string; // 학생 참조 (읽기 전용)
courseId: string; // 강의 참조 (읽기 전용)
enrolledAt: DateTime; // 수강 시작일
progress: number; // 진도율 (0~100)
completed: boolean; // 수료 여부
expireTime: DateTime; // 수강권 만료일
}
핵심 체크포인트
- ✅ 다대다 관계는 단순 참조(13장)로는 부족하다
- ✅ 관계 자체에 메타데이터(역할, 시각 등)를 저장해야 할 때 연관 리소스 사용
- ✅ DB의 join table = API의 연관 리소스
- ✅ 교차 참조는 일대다에 적합, 연관 리소스는 다대다에 적합
14.2 개괄 — 연관 리소스의 기본 개념
한 줄 요약
연관 리소스는 DB의 join table을 API 리소스로 노출한 것이다. 생성=가입, 삭제=탈퇴.
데이터 모델
개체 관계 데이터 모델
┌───────┐ ┌───────┐ ┌──────┐ ┌───────────┐ ┌───────┐
│ Group │◄───────►│ User │ │ User │◄─┤ UserGroup │─►│ Group │
│ •id │ 다대다 │ •id │ │ •id │ │ •id │ │ •id │
│ •name │ │ •name │ │ •name│ │ •userId │ │ •name │
└───────┘ └───────┘ └──────┘ │ •groupId │ └───────┘
│ •role │
│ •joinedAt │
└───────────┘
실무 예제로 이해하는 연관 리소스
예제 1: Slack 워크스페이스의 채널 멤버십
Slack에서 사용자는 여러 채널에 참여하고, 채널에는 여러 사용자가 있다.
사용자: Alice, Bob, Charlie
채널: #general, #engineering, #random
관계 데이터:
┌────────────────────────────────────────────────────────┐
│ ChannelMembership │
├──────┬──────────┬──────────────┬────────┬──────────────┤
│ id │ userId │ channelId │ role │ joinedAt │
├──────┼──────────┼──────────────┼────────┼──────────────┤
│ cm-1 │ Alice │ #general │ admin │ 2024-01-01 │
│ cm-2 │ Alice │ #engineering │ member │ 2024-01-15 │
│ cm-3 │ Bob │ #general │ member │ 2024-02-01 │
│ cm-4 │ Bob │ #engineering │ admin │ 2024-01-10 │
│ cm-5 │ Charlie │ #random │ member │ 2024-03-01 │
└──────┴──────────┴──────────────┴────────┴──────────────┘
핵심: Alice는 #general에서 admin이지만 #engineering에서 member
→ 같은 사용자라도 채널마다 다른 역할! → 관계에 메타데이터가 필요!
예제 2: GitHub의 레포지토리 협력자
// GitHub API에서 실제 사용하는 패턴과 유사
interface RepositoryCollaboration {
id: string;
repositoryId: string; // 레포지토리 참조
userId: string; // 사용자 참조
permission: "read" | "write" | "admin"; // 권한 수준
invitedAt: DateTime; // 초대 시점
acceptedAt: DateTime; // 수락 시점 (null이면 미수락)
}
// 사용 예시
// "Alice에게 my-repo에 write 권한 부여"
POST /collaborations
{
repositoryId: "repos/my-repo",
userId: "users/alice",
permission: "write"
}
// → RepositoryCollaboration { id: "collab-1", ..., invitedAt: "2024-03-15" }
연관 리소스의 3가지 강점
- 관계 CRUD: 생성(가입), 조회(멤버십 확인), 삭제(탈퇴)
- 메타데이터 저장: 역할(admin/user), 가입일, 만료일 등
- 양방향 탐색: "이 사용자가 속한 그룹은?" + "이 그룹의 사용자는?"
14.2.1 연관성 별칭 메서드
자주 쓰는 쿼리를 단순화한 편의 메서드이다.
# 일반적 방식: 필터로 조회
ListMemberships({ filter: "userId='users/jimmy'" }) → Jimmy가 속한 그룹
ListMemberships({ filter: "groupId='groups/2'" }) → 그룹 2의 사용자
# 별칭 메서드: 훨씬 직관적
ListUserGroups({ userId: "users/jimmy" }) → Jimmy가 속한 그룹
ListGroupUsers({ groupId: "groups/2" }) → 그룹 2의 사용자
비유: 카페에서 주문하기
일반 방식: "메뉴에서 음료 카테고리의 따뜻한 것 중에서 에스프레소 더블샷 주세요"
별칭 방식: "아아 주세요" ← 자주 쓰는 주문을 줄임말로!
ListMemberships({ filter: "..." }) ← 일반 방식 (유연하지만 길다)
ListGroupUsers({ groupId: "2" }) ← 별칭 (자주 쓰는 패턴을 단순화)
핵심 체크포인트
- ✅ 연관 리소스 = DB의 join table을 API 리소스로 노출
- ✅ 생성 = 관계 수립, 삭제 = 관계 해제
- ✅ 관계 메타데이터(역할, 가입일 등) 저장 가능
- ✅ 별칭 메서드로 자주 쓰는 조회 패턴을 단순화
14.2.5 실전 시나리오 — 온라인 강의 플랫폼을 처음부터 구축
이 섹션은 연관 리소스의 전체 흐름을 처음부터 끝까지 따라간다. 왜 13장/15장이 안 되는지, 왜 연관 리소스여야 하는지를 단계별로 체험한다.
STEP 1: 요구사항 분석
인프런 같은 온라인 강의 플랫폼을 만든다.
핵심 요구사항:
- 학생은 여러 강의를 수강할 수 있다
- 강의에는 여러 학생이 있을 수 있다
- 관계에 추가 정보가 필요하다:
- 수강 시작일 (enrolledAt)
- 진도율 (progress: 0~100)
- 수료 여부 (completed: true/false)
- 수강권 만료일 (expireTime)
→ 다대다 관계 + 메타데이터!
STEP 2: 13장 교차 참조로 시도 → 실패
// 시도: Student 리소스에 courseIds 배열을 넣는다
interface Student {
id: string;
name: string;
courseIds: string[]; // ["courses/python-101", "courses/react-basics"]
}
// 문제 1: 진도율은 어디에 저장?
// Student.progress? → 강의마다 진도가 다른데 하나만 저장?
// Student.courseProgress? → { "courses/python-101": 75, "courses/react-basics": 30 }
// → 맵을 쓰면 되긴 하지만, 수료 여부, 만료일도 각각 맵으로?
// → 필드가 폭발적으로 늘어남!
// 문제 2: "이 강의의 수강생 목록"을 어떻게 조회?
// → 모든 Student를 스캔하면서 courseIds에 해당 강의가 있는지 확인
// → O(N) 풀스캔! 학생 100만 명이면?
// 문제 3: 양방향 관리 불일치
// Student.courseIds를 수정하면 Course 쪽은 어떻게 알지?
// Course.studentIds도 관리해야 한다면? → 동기화 지옥!
결론: 교차 참조(13장)는 일대다 + 메타데이터 없음에 적합. 여기서는 부적합!
STEP 3: 15장 add/remove로 시도 → 부분 실패
// 시도: 관계를 add/remove로 관리
POST /courses/python-101/students:add { studentId: "students/alice" }
→ 200 OK ✅
// 문제: 진도율을 어디에 저장?
// add/remove는 관계의 존재 여부만 관리하므로 메타데이터 없음!
// "Alice의 python-101 진도율은 75%이다" → 표현할 방법이 없음!
// ListCourseStudents도 Student[]만 반환
GET /courses/python-101/students
→ [{ id: "students/alice", name: "Alice" }, ...]
// 진도율? 수료 여부? → 없음!
결론: add/remove(15장)는 다대다 + 메타데이터 없음에 적합. 여기서는 부적합!
STEP 4: 연관 리소스 설계 → 성공!
// === 리소스 인터페이스 ===
interface Student {
id: string; // "students/alice"
name: string;
email: string;
}
interface Course {
id: string; // "courses/python-101"
title: string;
instructor: string;
description: string;
}
// 연관 리소스 — 학생과 강의 사이의 "수강 등록" 관계
interface CourseEnrollment {
id: string; // "enrollments/enr-001"
studentId: string; // ← 읽기 전용 (어떤 학생?)
courseId: string; // ← 읽기 전용 (어떤 강의?)
enrolledAt: DateTime; // 수강 시작일 (서버 자동 설정)
progress: number; // 진도율 (0~100)
completed: boolean; // 수료 여부
expireTime: DateTime; // 수강권 만료일
}
-- === DB 스키마 ===
CREATE TABLE students (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
);
CREATE TABLE courses (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
instructor TEXT NOT NULL,
description TEXT
);
CREATE TABLE enrollments (
id TEXT PRIMARY KEY,
student_id TEXT NOT NULL REFERENCES students(id),
course_id TEXT NOT NULL REFERENCES courses(id),
enrolled_at DATETIME DEFAULT CURRENT_TIMESTAMP,
progress INTEGER DEFAULT 0,
completed BOOLEAN DEFAULT FALSE,
expire_time DATETIME,
UNIQUE(student_id, course_id) -- ← 같은 학생+강의 쌍은 1개만!
);
STEP 5: 전체 API 호출 시나리오
// ===== 1단계: 학생과 강의 생성 =====
POST /students
{ name: "Alice Kim", email: "alice@example.com" }
→ 201 Created { id: "students/alice", ... }
POST /courses
{ title: "Python 기초", instructor: "김파이썬", description: "초보자를 위한 파이썬" }
→ 201 Created { id: "courses/python-101", ... }
POST /courses
{ title: "React 입문", instructor: "이리액트", description: "프론트엔드 시작하기" }
→ 201 Created { id: "courses/react-basics", ... }
// ===== 2단계: 수강 등록 (연관 리소스 생성) =====
POST /enrollments
{
studentId: "students/alice",
courseId: "courses/python-101",
expireTime: "2026-12-31T23:59:59Z"
}
→ 201 Created
{
id: "enrollments/enr-001",
studentId: "students/alice", // 읽기 전용!
courseId: "courses/python-101", // 읽기 전용!
enrolledAt: "2026-03-17T09:00:00Z",// 서버가 자동 설정
progress: 0, // 초기값
completed: false, // 초기값
expireTime: "2026-12-31T23:59:59Z"
}
// Alice가 React도 수강 등록
POST /enrollments
{
studentId: "students/alice",
courseId: "courses/react-basics",
expireTime: "2026-12-31T23:59:59Z"
}
→ 201 Created { id: "enrollments/enr-002", ... }
// 중복 등록 시도 → 거부!
POST /enrollments
{ studentId: "students/alice", courseId: "courses/python-101" }
→ 409 Conflict "이미 수강 중입니다"
// ===== 3단계: 학습 진행 → 진도 업데이트 =====
// Alice가 Python 강의 5/10 강의를 수강 완료
PATCH /enrollments/enr-001
{ progress: 50 }
→ 200 OK
{
id: "enrollments/enr-001",
studentId: "students/alice", // 변경 안 됨 (읽기 전용)
courseId: "courses/python-101", // 변경 안 됨 (읽기 전용)
enrolledAt: "2026-03-17T09:00:00Z",
progress: 50, // ← 업데이트됨!
completed: false,
expireTime: "2026-12-31T23:59:59Z"
}
// ===== 4단계: 수료 처리 =====
// Alice가 Python 전 강의 수강 완료!
PATCH /enrollments/enr-001
{ progress: 100, completed: true }
→ 200 OK { ..., progress: 100, completed: true }
// ===== 5단계: 수강 정보 조회 =====
// 5-a: 특정 수강 정보 확인
GET /enrollments/enr-001
→ 200 OK { id: "enr-001", studentId: "alice", courseId: "python-101", progress: 100, ... }
// 5-b: Alice의 모든 수강 목록 (별칭 메서드)
GET /students/alice/courses
→ 200 OK
{
courses: [
{ id: "courses/python-101", title: "Python 기초", ... },
{ id: "courses/react-basics", title: "React 입문", ... }
]
}
// 5-c: Python 강의의 모든 수강생 (별칭 메서드)
GET /courses/python-101/students
→ 200 OK
{
students: [
{ id: "students/alice", name: "Alice Kim", ... },
{ id: "students/bob", name: "Bob Lee", ... }
]
}
// 5-d: 메타데이터가 필요하면 ListEnrollments 사용
GET /enrollments?filter=courseId='courses/python-101'
→ 200 OK
{
enrollments: [
{ id: "enr-001", studentId: "alice", progress: 100, completed: true, ... },
{ id: "enr-003", studentId: "bob", progress: 30, completed: false, ... }
]
}
// ===== 6단계: 수강 취소 =====
// Alice가 React 수강을 취소
DELETE /enrollments/enr-002
→ 204 No Content
// 확인: Alice의 수강 목록에서 React가 사라짐
GET /students/alice/courses
→ { courses: [{ id: "courses/python-101", ... }] }
// React는 더 이상 없음!
이 시나리오에서 배운 것
┌──────────────────────────────────────────────────────────────────┐
│ 13장 교차 참조: Student에 courseIds[] → 메타데이터 저장 불가 ❌ │
│ 15장 add/remove: AddCourseStudent → 메타데이터 저장 불가 ❌ │
│ 14장 연관 리소스: CourseEnrollment → 메타데이터 저장 ✅ │
│ │
│ 연관 리소스가 필요한 순간: │
│ "관계 자체에 대한 질문"이 있을 때 │
│ │
│ - "Alice의 Python 진도율은?" → Enrollment.progress │
│ - "Alice는 Python을 수료했나?" → Enrollment.completed │
│ - "Alice의 수강권은 언제 만료?" → Enrollment.expireTime │
│ │
│ 이런 질문에 답하려면 관계 자체가 리소스여야 한다! │
└──────────────────────────────────────────────────────────────────┘
14.3 구현
14.3.1 연관 리소스 이름 붙이기
한 줄 요약
도메인에서 자연스러운 이름을 먼저 찾고, 없으면 리소스명+Membership/Association을 사용한다.
네이밍 규칙
| 방식 | 예시 | 적합한 상황 |
|---|---|---|
| 도메인 용어 | CourseEnrollment, Subscription | 도메인에 이미 적절한 용어가 있을 때 (가장 좋음) |
| 리소스명 + Membership | ChannelMembership, TeamMembership | "멤버"라는 개념이 자연스러울 때 |
| 리소스명 + Association | UserProjectAssociation | 중립적인 관계를 표현할 때 |
| 단독 Membership | Membership | API에 관계 타입이 하나뿐이고 맥락이 명확할 때 |
실무 예시 — 이름 짓기 실전
온라인 강의 플랫폼:
학생 ↔ 강의 → CourseEnrollment ✅ ("수강 등록"이라는 도메인 용어)
강사 ↔ 강의 → CourseInstructorship ❌ (어색함)
→ CourseTeaching ✅ (또는 InstructorAssignment)
Slack 클론:
사용자 ↔ 채널 → ChannelMembership ✅ ("채널 멤버십"이 자연스러움)
사용자 ↔ 워크스페이스 → WorkspaceMembership ✅
GitHub 클론:
사용자 ↔ 레포 → RepositoryCollaboration ✅ ("협업자" 개념이 자연스러움)
사용자 ↔ 조직 → OrganizationMembership ✅
쇼핑몰:
상품 ↔ 카테고리 → ProductCategorization ✅ (또는 ProductCategoryMapping)
사용자 ↔ 쿠폰 → CouponAssignment ✅
14.3.2 표준 메서드 동작
한 줄 요약
연관 리소스도 일반 리소스처럼 CRUD를 지원하되, Create/List/Delete는 필수이고 Get/Update는 메타데이터가 있을 때만.
표준 메서드 상세
| 표준 메서드 | 동작 | 필요성 | HTTP |
|---|---|---|---|
| Create | 두 리소스를 연결 | 필수 | POST /memberships |
| Get | 관계 메타데이터 확인 | 메타데이터 시 | GET /memberships/{id} |
| List | 리소스 간의 연관성 나열 | 필수 | GET /memberships |
| Update | 관계 메타데이터 수정 | 메타데이터 시 | PATCH /memberships/{id} |
| Delete | 리소스 간의 연관성 제거 | 필수 | DELETE /memberships/{id} |
실무 예제 — 채널 멤버십 전체 CRUD
// ===== 1. CREATE: 사용자를 채널에 추가 =====
POST /memberships
{
channelId: "channels/engineering",
userId: "users/alice",
role: "member"
}
→ 201 Created
{
id: "memberships/cm-101",
channelId: "channels/engineering", // 읽기 전용!
userId: "users/alice", // 읽기 전용!
role: "member",
joinedAt: "2024-03-15T09:00:00Z" // 서버가 자동 설정
}
// ===== 2. GET: 특정 멤버십 정보 확인 =====
GET /memberships/cm-101
→ 200 OK
{
id: "memberships/cm-101",
channelId: "channels/engineering",
userId: "users/alice",
role: "member",
joinedAt: "2024-03-15T09:00:00Z"
}
// ===== 3. LIST: 전체 멤버십 나열 (필터 가능) =====
GET /memberships?filter=channelId='channels/engineering'
→ 200 OK
{
memberships: [
{ id: "cm-101", userId: "users/alice", role: "member", ... },
{ id: "cm-102", userId: "users/bob", role: "admin", ... }
],
nextPageToken: "..." // 페이징 지원
}
// ===== 4. UPDATE: 역할 변경 (메타데이터만!) =====
PATCH /memberships/cm-101
{
role: "admin" // ← 메타데이터 변경 OK
// userId를 여기에 넣어도 무시됨! (읽기 전용)
}
→ 200 OK
{
id: "memberships/cm-101",
channelId: "channels/engineering",
userId: "users/alice", // 변경 안 됨
role: "admin", // ← 변경됨!
joinedAt: "2024-03-15T09:00:00Z"
}
// ===== 5. DELETE: 채널에서 사용자 제거 =====
DELETE /memberships/cm-101
→ 204 No Content
메타데이터 없는 경우 — 간소화
// 태그 시스템: 게시글 ↔ 태그 (메타데이터 없음)
// → Get, Update가 불필요
interface PostTagging {
id: string;
postId: string; // 읽기 전용
tagId: string; // 읽기 전용
// 메타데이터 없음!
}
// Create, List, Delete만 지원
POST /taggings { postId: "posts/1", tagId: "tags/javascript" }
GET /taggings → 전체 태그 매핑 나열
DELETE /taggings/t-1 → 태그 제거
14.3.3 고유성
한 줄 요약
같은 두 리소스를 연결하는 연관 리소스는 딱 하나만 존재할 수 있다.
왜 고유성이 필요한가?
# Alice를 #engineering에 2번 추가하면?
POST /memberships { userId: "users/alice", channelId: "channels/engineering" }
→ 201 Created ✅ (처음이니까 성공)
POST /memberships { userId: "users/alice", channelId: "channels/engineering" }
→ 409 Conflict ❌ (이미 존재!)
# 왜 막아야 하나?
# 1. Alice가 채널에 2번 추가되면 혼란 (멤버 목록에 2번 나옴)
# 2. 어떤 Membership의 role을 수정해야 하는지 모호해짐
# 3. 삭제할 때 하나만 삭제하면 여전히 관계가 남아있음
일반 리소스 vs 연관 리소스의 고유성 차이
# 일반 리소스: ID가 같으면 충돌
POST /users { id: "users/alice" } → 201 Created
POST /users { id: "users/alice" } → 409 Conflict (같은 ID!)
# 연관 리소스: 참조 쌍이 같으면 충돌 (ID가 달라도!)
POST /memberships { userId: "alice", channelId: "eng" }
→ memberships/cm-1 Created
POST /memberships { userId: "alice", channelId: "eng" }
→ 409 Conflict (alice+eng 쌍이 이미 존재!)
# memberships/cm-2가 되더라도 alice+eng 쌍의 중복이므로 거부!
14.3.4 읽기 전용 필드
한 줄 요약
관계의 양쪽 참조(userId, groupId)는 생성 시 설정되고, 이후 변경할 수 없다.
왜 읽기 전용인가?
# 현재 상태: Alice가 #engineering의 admin
memberships/cm-101: { userId: "alice", channelId: "engineering", role: "admin" }
# 만약 userId를 변경할 수 있다면?
PATCH /memberships/cm-101 { userId: "bob" }
→ "Alice의 engineering admin 멤버십"이 "Bob의 engineering admin 멤버십"으로 바뀜
→ Alice는 갑자기 engineering에서 퇴장!
→ Bob은 본인 의지와 무관하게 admin이 됨!
# 이건 "관계 수정"이 아니라 "다른 관계"임!
# 올바른 방법: 기존 멤버십 삭제 + 새 멤버십 생성
DELETE /memberships/cm-101 # Alice 제거
POST /memberships { userId: "bob", channelId: "engineering", role: "admin" } # Bob 추가
비유: 결혼 증명서
결혼 증명서에는 두 사람의 이름이 있다.
- "신랑: 김철수, 신부: 이영희, 결혼일: 2024-03-15"
이 증명서에서 "신랑: 박민수"로 바꿀 수 있는가? → 불가능!
그건 다른 결혼이다. 이혼(삭제)하고 새로 결혼(생성)해야 한다.
연관 리소스도 마찬가지:
- userId, groupId는 "누구와 누구의 관계"를 정의하는 핵심 필드
- 이것을 바꾸면 다른 관계가 되므로 변경 불가 (읽기 전용)
- 메타데이터(role, expireTime)만 업데이트 가능
Update 시 읽기 전용 필드 처리 방식
// 방식 1: 조용히 무시 (권장)
PATCH /memberships/cm-101
{ userId: "bob", role: "admin" }
→ 200 OK { userId: "alice", role: "admin" }
// userId 변경은 무시, role만 반영
// 방식 2: 에러 반환 (엄격)
PATCH /memberships/cm-101
{ userId: "bob", role: "admin" }
→ 400 Bad Request "userId is read-only and cannot be modified"
// 어느 방식이든 userId는 절대 변경되지 않는다
14.3.5 연관 별칭 메서드
한 줄 요약
자주 쓰는 "특정 리소스의 연관 목록" 쿼리를 전용 메서드로 제공한다.
상세 비교표
표 14.2 자주 쓰이는 연관 리소스 쿼리의 별칭
┌──────────────────┬───────────────────────────┬──────────────────────────┐
│ 질문 │ 리스트 필터링 │ 별칭 메서드 │
├──────────────────┼───────────────────────────┼──────────────────────────┤
│ Jimmy가 속한 │ ListMemberships({ │ ListUserGroups({ │
│ 그룹은? │ filter: "userId: Jimmy" │ userId: "Jimmy" │
│ │ }) │ }) │
├──────────────────┼───────────────────────────┼──────────────────────────┤
│ 그룹 2에 속한 │ ListMemberships({ │ ListGroupUsers({ │
│ 사용자는? │ filter: "groupId: 2" │ groupId: "2" │
│ │ }) │ }) │
└──────────────────┴───────────────────────────┴──────────────────────────┘
네이밍 규칙 — 주어진 리소스(단수) + 나열할 리소스(복수)
ListUser Groups → 주어진 "사용자"에 대한 "그룹들" 나열
↑ ↑
단수 복수
ListGroup Users → 주어진 "그룹"에 대한 "사용자들" 나열
↑ ↑
단수 복수
HTTP URL 패턴
# ListUserGroups: "이 사용자가 속한 그룹들"
GET /users/{userId}/groups
GET /users/jimmy/groups
→ [{ id: "groups/1", name: "축구부" }, { id: "groups/3", name: "밴드부" }]
# ListGroupUsers: "이 그룹에 속한 사용자들"
GET /groups/{groupId}/users
GET /groups/1/users
→ [{ id: "users/jimmy", name: "Jimmy" }, { id: "users/sally", name: "Sally" }]
별칭 메서드가 반환하는 것
# 핵심: 별칭 메서드는 Membership이 아니라 "연관된 리소스"를 반환!
ListGroupUsers({ groupId: "groups/1" })
→ 반환: User[] (사용자 목록!) — Membership[]이 아님!
ListUserGroups({ userId: "users/jimmy" })
→ 반환: Group[] (그룹 목록!) — Membership[]이 아님!
# Membership 자체가 필요하면 ListMemberships 사용
ListMemberships({ filter: "groupId='groups/1'" })
→ 반환: Membership[] (멤버십 목록 + 메타데이터 포함)
14.3.6 참조 무결성
한 줄 요약
연관 리소스가 가리키는 대상이 삭제되면 어떻게 처리할 것인가?
4가지 대응 방식 상세
상황: Group "축구부"를 삭제하려 하는데,
Jimmy와 Sally의 Membership이 이 Group을 참조 중
┌──────────────────────────────────────────────────────────────┐
│ 방식 1: 캐스케이드 (Cascade) │
│ → Group 삭제 시 관련 Membership도 전부 삭제 │
│ │
│ DELETE /groups/축구부 │
│ → Group 삭제됨 │
│ → Jimmy의 축구부 Membership 자동 삭제 │
│ → Sally의 축구부 Membership 자동 삭제 │
│ │
│ 장점: 허상 포인터 없음 │
│ 단점: 100만 명의 Membership이 있으면? → 삭제에 수 분 소요 │
│ 연쇄 삭제 위험 (Membership 삭제가 또 다른 삭제 유발) │
│ 결론: ⚠️ 대규모에서 비현실적 │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 방식 2: 제한 (Restrict) ← 권장! │
│ → Membership이 참조하는 한 Group 삭제 불가 │
│ │
│ DELETE /groups/축구부 │
│ → 409 Conflict "2개의 Membership이 이 Group을 참조 중입니다. │
│ 먼저 해당 Membership을 삭제해주세요." │
│ │
│ # 올바른 절차: │
│ DELETE /memberships/cm-1 (Jimmy 멤버십 삭제) │
│ DELETE /memberships/cm-2 (Sally 멤버십 삭제) │
│ DELETE /groups/축구부 (이제 삭제 가능!) │
│ │
│ 장점: 가장 안전, 의도치 않은 데이터 유실 방지 │
│ 단점: 삭제 전 정리 작업 필요 │
│ 결론: ✅ 연관 리소스에 가장 권장 │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 방식 3: null 설정 (Set Null) │
│ → Group 삭제 시 관련 Membership의 groupId를 null로 │
│ │
│ DELETE /groups/축구부 │
│ → Group 삭제됨 │
│ → Jimmy의 Membership: { groupId: null, ... } │
│ │
│ 장점: 허상 포인터 없음 │
│ 단점: 100만 건 업데이트 필요, groupId가 null인 Membership이 │
│ 의미가 있는가? (어떤 그룹의 멤버인지 모름) │
│ 결론: ⚠️ 연관 리소스에서는 부적합 │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 방식 4: 방치 (Leave as-is) │
│ → Group 삭제해도 Membership은 그대로 둠 │
│ │
│ DELETE /groups/축구부 │
│ → Group 삭제됨 │
│ → Jimmy의 Membership: { groupId: "groups/축구부", ... } │
│ (여전히 삭제된 그룹을 가리킴 → 허상 포인터!) │
│ │
│ 장점: 단순, 삭제 메서드의 원자성/부수효과 없음 제약 충족 │
│ 단점: 클라이언트가 참조 유효성을 항상 확인해야 함 │
│ 결론: ✅ 단순한 시스템에서 사용 가능 │
└──────────────────────────────────────────────────────────────┘
실무 권장
1순위: 제한 (Restrict) — 가장 안전
"Membership이 있으면 Group 삭제를 막는다"
→ 관리자가 명시적으로 멤버를 먼저 정리해야 삭제 가능
2순위: 방치 (Leave as-is) — 가장 단순
"삭제하고 허상 포인터는 클라이언트가 처리"
→ 13장에서 교차 참조의 권장 방식과 동일
비추천: 캐스케이드, null 설정 — 대규모에서 비현실적
14.3.6.1 백엔드 구현 — Flask + SQLite로 직접 만들어보기
연관 리소스의 핵심 동작을 실제 서버 코드로 구현한다. 이 코드를 복사하면 로컬에서 바로 실행할 수 있다.
DB 스키마
# schema.sql — 테이블 생성
import sqlite3
def init_db():
"""데이터베이스 초기화 — users, groups, memberships 테이블 생성"""
db = sqlite3.connect("membership.db")
db.executescript("""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS groups_ (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT
);
CREATE TABLE IF NOT EXISTS memberships (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
group_id TEXT NOT NULL REFERENCES groups_(id),
role TEXT DEFAULT 'member',
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expire_time DATETIME,
UNIQUE(user_id, group_id) -- ← 고유성 제약: 같은 쌍은 1개만!
);
""")
db.close()
CreateMembership — 연관 리소스 생성 (409 Conflict 처리)
from flask import Flask, request, jsonify
import sqlite3, uuid
app = Flask(__name__)
@app.route("/memberships", methods=["POST"])
def create_membership():
"""
연관 리소스 생성 — 두 리소스를 연결한다.
동작 흐름:
1. 요청에서 userId, groupId 추출
2. 해당 User, Group이 존재하는지 확인 → 404
3. 같은 쌍이 이미 존재하는지 확인 → 409 Conflict
4. INSERT → 201 Created
"""
data = request.json
user_id = data["userId"]
group_id = data["groupId"]
db = sqlite3.connect("membership.db")
# 참조 대상 존재 확인
user = db.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
if not user:
return jsonify({"error": f"User '{user_id}' not found"}), 404
group = db.execute("SELECT id FROM groups_ WHERE id = ?", (group_id,)).fetchone()
if not group:
return jsonify({"error": f"Group '{group_id}' not found"}), 404
# 고유성 제약 확인
existing = db.execute(
"SELECT id FROM memberships WHERE user_id = ? AND group_id = ?",
(user_id, group_id)
).fetchone()
if existing:
return jsonify({"error": "이미 이 그룹의 멤버입니다"}), 409
# 생성
membership_id = f"memberships/{uuid.uuid4().hex[:8]}"
role = data.get("role", "member")
db.execute(
"INSERT INTO memberships (id, user_id, group_id, role) VALUES (?, ?, ?, ?)",
(membership_id, user_id, group_id, role)
)
db.commit()
# 생성된 리소스 반환
row = db.execute("SELECT * FROM memberships WHERE id = ?", (membership_id,)).fetchone()
db.close()
return jsonify({
"id": row[0], "userId": row[1], "groupId": row[2],
"role": row[3], "joinedAt": row[4], "expireTime": row[5]
}), 201
ListGroupUsers — 별칭 메서드 (JOIN 쿼리)
@app.route("/groups/<group_id>/users", methods=["GET"])
def list_group_users(group_id):
"""
별칭 메서드 — "이 그룹에 속한 사용자들" 반환.
핵심: Membership[]이 아니라 User[]를 반환한다!
내부적으로 memberships JOIN users 쿼리를 실행한다.
"""
db = sqlite3.connect("membership.db")
rows = db.execute("""
SELECT u.id, u.display_name, u.email
FROM users u
JOIN memberships m ON u.id = m.user_id
WHERE m.group_id = ?
ORDER BY m.joined_at DESC
""", (group_id,)).fetchall()
db.close()
return jsonify({
"users": [
{"id": r[0], "displayName": r[1], "email": r[2]}
for r in rows
]
})
# 반대 방향도 동일한 패턴
@app.route("/users/<user_id>/groups", methods=["GET"])
def list_user_groups(user_id):
"""별칭 메서드 — "이 사용자가 속한 그룹들" 반환."""
db = sqlite3.connect("membership.db")
rows = db.execute("""
SELECT g.id, g.name, g.description
FROM groups_ g
JOIN memberships m ON g.id = m.group_id
WHERE m.user_id = ?
ORDER BY m.joined_at DESC
""", (user_id,)).fetchall()
db.close()
return jsonify({
"groups": [
{"id": r[0], "name": r[1], "description": r[2]}
for r in rows
]
})
DeleteGroup — 참조 무결성 (Restrict 방식)
@app.route("/groups/<group_id>", methods=["DELETE"])
def delete_group(group_id):
"""
그룹 삭제 — Restrict 방식의 참조 무결성.
동작 흐름:
1. 이 그룹을 참조하는 Membership이 있는지 확인
2. 있으면 → 409 Conflict (삭제 거부)
3. 없으면 → 삭제 진행 → 204 No Content
"""
db = sqlite3.connect("membership.db")
# 참조 무결성 확인 (Restrict)
count = db.execute(
"SELECT COUNT(*) FROM memberships WHERE group_id = ?", (group_id,)
).fetchone()[0]
if count > 0:
db.close()
return jsonify({
"error": f"{count}개의 멤버십이 이 그룹을 참조 중입니다. "
f"먼저 해당 멤버십을 삭제해주세요."
}), 409
db.execute("DELETE FROM groups_ WHERE id = ?", (group_id,))
db.commit()
db.close()
return "", 204
실행해보기
# 1. DB 초기화 + 서버 시작
python3 -c "from app import init_db; init_db()"
flask run --port 5002
# 2. 테스트 데이터 생성
curl -X POST http://localhost:5002/users \
-H "Content-Type: application/json" \
-d '{"id": "users/alice", "displayName": "Alice", "email": "alice@test.com"}'
curl -X POST http://localhost:5002/groups \
-H "Content-Type: application/json" \
-d '{"id": "groups/engineering", "name": "Engineering", "description": "개발팀"}'
# 3. 멤버십 생성
curl -X POST http://localhost:5002/memberships \
-H "Content-Type: application/json" \
-d '{"userId": "users/alice", "groupId": "groups/engineering", "role": "admin"}'
# → 201 Created
# 4. 중복 생성 시도
curl -X POST http://localhost:5002/memberships \
-H "Content-Type: application/json" \
-d '{"userId": "users/alice", "groupId": "groups/engineering"}'
# → 409 Conflict "이미 이 그룹의 멤버입니다"
# 5. 별칭 메서드로 조회
curl http://localhost:5002/groups/groups%2Fengineering/users
# → { "users": [{ "id": "users/alice", ... }] }
# 6. 그룹 삭제 시도 (멤버십 있으므로 거부)
curl -X DELETE http://localhost:5002/groups/groups%2Fengineering
# → 409 Conflict "1개의 멤버십이 이 그룹을 참조 중입니다"
14.3.7 최종 API 정의
abstract class GroupApi {
static version = "v1";
static title = "Group API";
// ===== User 표준 메서드 =====
@post("/users")
CreateUser(req: CreateUserRequest): User;
@get("/{id=users/*}")
GetUser(req: GetUserRequest): User;
@get("/users")
ListUsers(req: ListUsersRequest): ListUsersResponse;
@patch("/{resource.id=users/*}")
UpdateUser(req: UpdateUserRequest): User;
@delete("/{id=users/*}")
DeleteUser(req: DeleteUserRequest): void;
// ===== Group 표준 메서드 =====
@post("/groups")
CreateGroup(req: CreateGroupRequest): Group;
@get("/{id=groups/*}")
GetGroup(req: GetGroupRequest): Group;
@get("/groups")
ListGroups(req: ListGroupsRequest): ListGroupsResponse;
@patch("/{resource.id=groups/*}")
UpdateGroup(req: UpdateGroupRequest): Group;
@delete("/{id=groups/*}")
DeleteGroup(req: DeleteGroupRequest): void;
// ===== Membership 표준 메서드 (연관 리소스) =====
@post("/memberships")
CreateMembership(req: CreateMembershipRequest): Membership;
@get("/{id=memberships/*}")
GetMembership(req: GetMembershipRequest): Membership;
@patch("/{resource.id=memberships/*}")
UpdateMembership(req: UpdateMembershipRequest): Membership;
@delete("/{id=memberships/*}")
DeleteMembership(req: DeleteMembershipRequest): void;
@get("/memberships")
ListMemberships(req: ListMembershipsRequest): ListMembershipsResponse;
// ===== 별칭 메서드 =====
@get("/{groupId=groups/*}/users") // "이 그룹의 사용자들"
ListGroupUsers(req: ListGroupUsersRequest): ListGroupUsersResponse;
@get("/{userId=users/*}/groups") // "이 사용자의 그룹들"
ListUserGroups(req: ListUserGroupsRequest): ListUserGroupsResponse;
}
// 부모 리소스
interface User {
id: string; // "users/alice"
displayName: string;
email: string;
}
interface Group {
id: string; // "groups/engineering"
name: string;
description: string;
}
// 연관 리소스 — 두 리소스 간의 관계를 표현
interface Membership {
id: string; // "memberships/cm-101"
groupId: string; // ← 읽기 전용 (참조)
userId: string; // ← 읽기 전용 (참조)
role: string; // ← 관계 메타데이터 (수정 가능)
expireTime: DateTime; // ← 관계 메타데이터 (수정 가능)
joinedAt: DateTime; // ← 서버 자동 설정 (읽기 전용)
}
API 메서드 카운트
User 관련: 5개 (Create, Get, List, Update, Delete)
Group 관련: 5개 (Create, Get, List, Update, Delete)
Membership 관련: 5개 (Create, Get, List, Update, Delete)
별칭: 2개 (ListGroupUsers, ListUserGroups)
────────────────────────────────────────────
총 17개 메서드
핵심 체크포인트
- ✅ Create/List/Delete는 필수, Get/Update는 메타데이터 존재 시 필요
- ✅ 같은 두 리소스 쌍의 연관은 하나만 (고유성 제약)
- ✅ userId/groupId는 읽기 전용 — 관계 대상 변경은 삭제 후 재생성
- ✅ 별칭 메서드: ListUserGroups, ListGroupUsers (단수+복수 패턴)
- ✅ 참조 무결성: 제한(삭제 막기) 또는 방치(허상 허용) 권장
- ✅ Membership은 최상위 컬렉션에 배치 (어느 한쪽의 서브리소스 아님)
14.4 트레이드오프
14.4.1 복잡성
연관성당 최대 7개 메서드(표준 5개 + 별칭 2개)를 추가해야 하므로 API 서피스가 커진다.
# 단순 접근: 사용자 메서드 1개
POST /groups/1/users:add { userId: "alice" } ← 15장 방식 (단순하지만 메타데이터 불가)
# 연관 리소스: 최대 7개 메서드
POST /memberships ← CreateMembership
GET /memberships/{id} ← GetMembership
GET /memberships ← ListMemberships
PATCH /memberships/{id} ← UpdateMembership
DELETE /memberships/{id} ← DeleteMembership
GET /groups/{id}/users ← ListGroupUsers (별칭)
GET /users/{id}/groups ← ListUserGroups (별칭)
→ 메타데이터가 필요 없으면 15장의 add/remove가 더 적합할 수 있다!
14.4.2 연관성 분리
그룹의 설명과 멤버 목록은 모두 그룹에 관한 정보이지만, 취득 방식이 완전히 다르다.
# 그룹의 기본 정보
GET /groups/1
→ { id: "groups/1", name: "축구부", description: "주말 축구 모임" }
# 그룹의 멤버 목록 — 별도 API 호출 필요!
GET /groups/1/users
→ [{ id: "users/alice", name: "Alice" }, { id: "users/bob", name: "Bob" }]
# 한 번에 모든 정보를 가져올 수 없음
# → 클라이언트가 2번 호출해야 함
14.4.3 언제 연관 리소스를 선택해야 하는가?
관계 메타데이터가 필요한가?
├── YES → 연관 리소스 (14장) 사용!
│ 예: 역할, 가입일, 만료일, 권한 수준 등
│
└── NO → 관계의 존재 여부만 필요
├── API 복잡도를 줄이고 싶다 → add/remove (15장) 사용
└── CRUD 일관성을 유지하고 싶다 → 연관 리소스 (14장) 사용 (메타데이터 없이)
14.4.4 실전 의사결정 — 5가지 실무 시나리오로 판단 연습
아래 시나리오를 보고 13장/14장/15장 중 어떤 패턴을 쓸지 직접 판단해보자. 답을 보기 전에 먼저 스스로 생각한 뒤 확인하자.
빠른 판단 플로우차트
Q1: 일대다인가 다대다인가?
│
├── 일대다 (한 책에 한 저자, 한 주문에 한 고객)
│ → 13장 교차 참조 (authorId, customerId)
│
└── 다대다 (학생 ↔ 강의, 사용자 ↔ 그룹)
│
└── Q2: 관계에 메타데이터가 필요한가?
│ (역할, 가입일, 진도율, 권한 수준 등)
│
├── YES → 14장 연관 리소스
│
└── NO → Q3: 나중에 메타데이터가 추가될 가능성은?
│
├── 높음 → 14장 연관 리소스 (마이그레이션 비용 방지)
│ 15장→14장 전환은 breaking change!
│
└── 낮음 → 15장 add/remove (가장 단순)
시나리오 1: GitHub Star (User ↔ Repository)
요구사항: 사용자가 저장소에 Star를 찍거나 취소할 수 있다.
관계 데이터: 없음. "찍었다/안 찍었다"가 전부.
→ 정답: 15장 add/remove ✅
AddRepositoryStar({ parent: "repos/my-repo", userId: "users/alice" })
RemoveRepositoryStar({ parent: "repos/my-repo", userId: "users/alice" })
왜? Star에 역할도 없고 기한도 없다.
만약 14장을 쓰면? StarMembership이라는 불필요한 리소스가 생기고,
Get/Update/Delete 메서드까지 만들어야 한다 → 과잉 설계!
시나리오 2: Slack 채널 멤버 (User ↔ Channel)
요구사항: 사용자가 채널에 가입/탈퇴할 수 있다.
관계 데이터:
- 역할 (admin, member, guest)
- 가입일
- 알림 설정 (all, mentions_only, none)
- 마지막 읽은 메시지 위치
→ 정답: 14장 연관 리소스 ✅
ChannelMembership { userId, channelId, role, joinedAt, notifyPreference, lastReadMessageId }
왜? 관계에 4개의 메타데이터가 필요하다.
만약 15장을 쓰면? "Alice는 #general의 admin인가 member인가?"를
표현할 방법이 없다 → 핵심 기능 누락!
시나리오 3: 블로그 태그 (Post ↔ Tag)
요구사항: 게시글에 태그를 붙이거나 떼어낼 수 있다.
관계 데이터: 없음. "이 태그가 붙어있다/없다"가 전부.
→ 정답: 15장 add/remove ✅
AddPostTag({ parent: "posts/123", tagId: "tags/javascript" })
왜? 태그에 역할이나 기한이 없다.
주의: "태그 순서"가 필요하면? → 순서는 관계 메타데이터이므로 14장!
PostTagging { postId, tagId, order: number }
시나리오 4: 수강 등록 (Student ↔ Course)
요구사항: 학생이 강의를 수강 등록/취소할 수 있다.
관계 데이터:
- 진도율 (0~100%)
- 수료 여부
- 수강 시작일
- 수강권 만료일
→ 정답: 14장 연관 리소스 ✅
CourseEnrollment { studentId, courseId, progress, completed, enrolledAt, expireTime }
왜? 진도율과 수료 여부는 핵심 비즈니스 데이터이다.
만약 15장을 쓰면? "Alice의 Python 진도율은?"이라는 질문에 답할 수 없다!
시나리오 5: 팀 프로젝트 배정 (Employee ↔ Project)
요구사항: 직원을 프로젝트에 배정/해제할 수 있다.
관계 데이터:
- 역할 (PM, developer, designer, QA)
- 참여율 (50%, 100% 등)
- 배정 시작일, 종료일
→ 정답: 14장 연관 리소스 ✅
ProjectAssignment { employeeId, projectId, role, allocation, startDate, endDate }
왜? "김개발자가 프로젝트 A에서 어떤 역할인지", "참여율이 얼마인지"는
프로젝트 관리의 핵심 질문이다.
만약 15장을 쓰면? 배정 정보가 없어서 리소스 관리가 불가능!
정리: 잘못 선택하면 생기는 문제
| 올바른 선택 | 잘못된 선택 | 발생하는 문제 |
|---|---|---|
| 14장 → 15장 사용 | 메타데이터 저장 불가 | 핵심 비즈니스 데이터 누락, 나중에 14장으로 마이그레이션 필요 (breaking change) |
| 15장 → 14장 사용 | 불필요한 리소스/메서드 7개 | API 복잡도 증가, 클라이언트 부담 가중, 과잉 설계 |
| 13장 → 14장 사용 | 일대다에 join table 사용 | 불필요한 중간 테이블, 단순한 FK 참조로 충분한 것을 복잡하게 만듦 |
| 14장 → 13장 사용 | 다대다를 배열 참조로 처리 | 양방향 탐색 비효율, 배열 비대화, 메타데이터 저장 불가 |
14.5 연습문제 풀이
Q1: 온라인 쇼핑몰에서 사용자와 상품의 "위시리스트" 관계를 설계하라.
A: 메타데이터가 있으므로 연관 리소스가 적합하다.
typescript interface WishlistItem { id: string; userId: string; // 읽기 전용 productId: string; // 읽기 전용 addedAt: DateTime; // 추가 시점 priority: "high" | "medium" | "low"; // 우선순위 note: string; // 메모 ("생일 선물용") }메타데이터(우선순위, 메모)가 없다면 15장의 add/remove가 더 적합하다.
Q2: 연관 리소스가 최상위 컬렉션(/memberships)에 있어야 하는 이유는?
A: Membership은 User나 Group 어느 한쪽의 하위가 아니라, 두 리소스 간의 독립적 관계이다. /groups/1/memberships로 두면 사용자 관점의 조회가 어렵고, /users/1/memberships로 두면 그룹 관점의 조회가 어렵다. 최상위에 두면 양쪽 모두에서 필터링이 가능하다.
Q3: 다음 중 연관 리소스가 필요한 경우와 불필요한 경우를 구분하라. 1. 학생 ↔ 수업 (출석률, 성적 기록) → 필요 (메타데이터 있음) 2. 게시글 ↔ 태그 (단순 매핑) → 불필요 (15장 add/remove로 충분) 3. 의사 ↔ 환자 (담당 시작일, 전문 분야) → 필요 (메타데이터 있음) 4. 사용자 ↔ 팔로워 (팔로우 여부만) → 불필요 (15장 add/remove로 충분)
부록 A: 용어 사전
| 용어 | 영문 | 의미 |
|---|---|---|
| 연관 리소스 | Association Resource | 두 리소스 간 다대다 관계를 표현하는 독립 리소스 |
| join table | Join Table | DB에서 다대다 관계를 저장하는 매핑 테이블 |
| 별칭 메서드 | Aliased Method | 자주 쓰이는 필터링 쿼리를 단순화한 메서드 |
| 고유성 제약 | Uniqueness Constraint | 동일 연관이 중복 생성되지 않도록 하는 제약 |
| 참조 무결성 | Referential Integrity | 모든 참조가 유효한 대상을 가리키는 상태 |
| 읽기 전용 필드 | Read-only Field | 생성 후 변경할 수 없는 필드 |
| 관계 메타데이터 | Relationship Metadata | 관계 자체에 대한 추가 정보 (역할, 가입일 등) |
| 캐스케이드 삭제 | Cascade Delete | 참조 대상 삭제 시 연관 리소스도 함께 삭제 |
| 제한 | Restrict | 연관 리소스가 참조 중이면 대상 삭제를 막음 |
부록 B: 핵심 비교표
관계 모델링 방식 비교 (13장 vs 14장 vs 15장)
| 항목 | 교차 참조 (13장) | 연관 리소스 (14장) | add/remove (15장) |
|---|---|---|---|
| 관계 유형 | 일대일/일대다 | 다대다 | 다대다 |
| 메타데이터 | 없음 | ✅ 가능 | 없음 |
| 리소스 추가 | 없음 | 연관 리소스 1개 | 없음 |
| 메서드 추가 | 없음 | 최대 7개 | 4개 |
| 복잡도 | 낮음 | 높음 | 중간 |
| 양방향 탐색 | 어려움 | ✅ 별칭 메서드 | ✅ 리스트 메서드 |
| 관계의 대칭성 | - | ✅ 대칭 | ❌ 비대칭 |
참조 무결성 방식 비교
| 방식 | 허상 포인터 | 대규모 적합 | 삭제 복잡도 | 권장 |
|---|---|---|---|---|
| 캐스케이드 | 없음 | ❌ | 높음 (연쇄 삭제) | ⚠️ |
| 제한 | 없음 | ✅ | 중간 (사전 정리) | ✅ 1순위 |
| null 설정 | 없음 | ❌ | 높음 (대량 업데이트) | ⚠️ |
| 방치 | 있음 | ✅ | 낮음 (즉시 삭제) | ✅ 2순위 |
부록 C: 추천 참고 자료 & 링크
| 자료 | 설명 |
|---|---|
| AIP-124 (google.aip.dev) | Google의 연관 리소스(Resource association) 가이드라인 |
| 13장 교차 참조 | 단순 참조의 기본 (연관 리소스의 선수 지식) |
| 15장 add/remove | 연관 리소스의 더 단순한 대안 |
| 7장 표준 메서드 | CRUD 메서드의 기본 원리 |
클릭하거나 Space를 눌러 뒤집기